home *** CD-ROM | disk | FTP | other *** search
/ Clickx 47 / Clickx 47.iso / assets / software / Miro_Installer.exe / xulrunner / python / item.py < prev    next >
Encoding:
Python Source  |  2008-01-10  |  68.8 KB  |  1,945 lines

  1. # Miro - an RSS based video player application
  2. # Copyright (C) 2005-2007 Participatory Culture Foundation
  3. #
  4. # This program is free software; you can redistribute it and/or modify
  5. # it under the terms of the GNU General Public License as published by
  6. # the Free Software Foundation; either version 2 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12. # GNU General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU General Public License
  15. # along with this program; if not, write to the Free Software
  16. # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
  17.  
  18. from copy import copy
  19. from datetime import datetime, timedelta
  20. from gtcache import gettext as _
  21. from math import ceil
  22. from xhtmltools import unescape,xhtmlify
  23. from xml.sax.saxutils import unescape
  24. from util import checkU, returnsUnicode, checkF, returnsFilename, quoteUnicodeURL, stringify
  25. from platformutils import FilenameType
  26. import locale
  27. import os
  28. import os.path
  29. import urllib
  30. import urlparse
  31. import shutil
  32. import traceback
  33.  
  34. from download_utils import cleanFilename, nextFreeFilename
  35. from feedparser import FeedParserDict
  36.  
  37. from database import DDBObject, defaultDatabase, ObjectNotFoundError
  38. from database import DatabaseConstraintError
  39. from databasehelper import makeSimpleGetSet
  40. from iconcache import IconCache
  41. from templatehelper import escape,quoteattr
  42. import types
  43. import app
  44. import template
  45. import downloader
  46. import config
  47. import dialogs
  48. import eventloop
  49. import feed
  50. import filters
  51. import menu
  52. import prefs
  53. import resources
  54. import views
  55. import random
  56. import indexes
  57. import util
  58. import adscraper
  59. import autodler
  60. import moviedata
  61. import logging
  62. import platformutils
  63. import filetypes
  64. import searchengines
  65. import fileutil
  66. import imageresize
  67. import license
  68.  
  69. _charset = locale.getpreferredencoding()
  70.  
  71. class Item(DDBObject):
  72.     """An item corresponds to a single entry in a feed. It has a single url
  73.     associated with it.
  74.     """
  75.  
  76.     SMALL_ICON_SIZE = (108, 81)
  77.     BIG_ICON_SIZE = (226, 170)
  78.     ICON_CACHE_SIZES = [SMALL_ICON_SIZE, BIG_ICON_SIZE]
  79.  
  80.     def __init__(self, entry, linkNumber = 0, feed_id=None, parent_id=None):
  81.         self.feed_id = feed_id
  82.         self.parent_id = parent_id
  83.         self.isContainerItem = None
  84.         self.isVideo = False
  85.         self.seen = False
  86.         self.autoDownloaded = False
  87.         self.pendingManualDL = False
  88.         self.downloadedTime = None
  89.         self.watchedTime = None
  90.         self.pendingReason = u""
  91.         self.entry = entry
  92.         self.expired = False
  93.         self.keep = False
  94.         self.videoFilename = FilenameType("")
  95.         self.eligibleForAutoDownload = True
  96.         self.duration = None
  97.         self.screenshot = None
  98.         self.resized_screenshots = {}
  99.         self.resumeTime = 0
  100.         self.channelTitle = None
  101.  
  102.         self.iconCache = IconCache(self)
  103.         
  104.         # linkNumber is a hack to make sure that scraped items at the
  105.         # top of a page show up before scraped items at the bottom of
  106.         # a page. 0 is the topmost, 1 is the next, and so on
  107.         self.linkNumber = linkNumber
  108.         self.creationTime = datetime.now()
  109.         self.updateReleaseDate()
  110.         self._initRestore()
  111.         self._lookForFinishedDownloader()
  112.         DDBObject.__init__(self)
  113.         self.splitItem()
  114.  
  115.     ##
  116.     # Called by pickle during serialization
  117.     def onRestore(self):
  118.         if (self.iconCache == None):
  119.             self.iconCache = IconCache (self)
  120.         else:
  121.             self.iconCache.dbItem = self
  122.             self.iconCache.requestUpdate()
  123.         # For unknown reason(s), some users still have databases with item 
  124.         # objects missing the isContainerItem attribute even after
  125.         # a db upgrade (#8819).
  126.         if not hasattr(self, 'isContainerItem'):
  127.             self.isContainerItem = None
  128.         self._initRestore()
  129.  
  130.     def _initRestore(self):
  131.         """Common code shared between onRestore and __init__."""
  132.         self.selected = False
  133.         self.active = False
  134.         self.childrenSeen = None
  135.         self.downloader = None
  136.         self.expiring = None
  137.         self.showMoreInfo = False
  138.         self.updating_movie_info = False
  139.  
  140.     def _lookForFinishedDownloader(self):
  141.         dler = downloader.lookupDownloader(self.getURL())
  142.         if dler and dler.isFinished():
  143.             self.downloader = dler
  144.             dler.addItem(self)
  145.  
  146.     getSelected, setSelected = makeSimpleGetSet(u'selected',
  147.             changeNeedsSave=False)
  148.     getActive, setActive = makeSimpleGetSet(u'active', changeNeedsSave=False)
  149.  
  150.     @returnsUnicode
  151.     def getSelectedState(self, view):
  152.         currentView = app.controller.selection.itemListSelection.currentView
  153.         if not self.selected or view != currentView:
  154.             return u'normal'
  155.         elif not self.active:
  156.             return u'selected-inactive'
  157.         else:
  158.             return u'selected'
  159.  
  160.     def toggleShowMoreInfo(self):
  161.         self.showMoreInfo = not self.showMoreInfo
  162.         self.signalChange(needsSave=False, needsUpdateXML=True)
  163.  
  164.     @returnsUnicode
  165.     def getMoreInfoState(self):
  166.         if self.showMoreInfo:
  167.             return u'more-info'
  168.         return u''
  169.  
  170.     def findChildVideos(self):
  171.         """If this item points to a directory, return the set all video files
  172.         under that directory.
  173.         """
  174.  
  175.         videos = set()
  176.         filename_root = self.getFilename()
  177.         if os.path.isdir(filename_root):
  178.             for (dirpath, dirnames, filenames) in os.walk(filename_root):
  179.                 for name in filenames:
  180.                     filename = os.path.join (dirpath, name)
  181.                     if filetypes.isVideoFilename(filename) or filetypes.isAudioFilename(filename):
  182.                         videos.add(filename)
  183.         return videos
  184.  
  185.     def findNewChildren(self):
  186.         """If this feed is a container item, walk through its directory and
  187.         find any new children.  Returns True if it found childern and ran
  188.         signalChange().
  189.         """
  190.  
  191.         filename_root = self.getFilename()
  192.         if not self.isContainerItem:
  193.             return False
  194.         if self.getState() == 'downloading':
  195.             # don't try to find videos that we're in the middle of
  196.             # re-downloading
  197.             return False
  198.         videos = self.findChildVideos()
  199.         for child in self.getChildren():
  200.             videos.discard(child.getFilename())
  201.         for video in videos:
  202.             assert video.startswith(filename_root)
  203.             offsetPath = video[len(filename_root):]
  204.             if offsetPath[0] == '/':
  205.                 offsetPath = offsetPath[1:]
  206.             FileItem (video, parent_id=self.id, offsetPath=offsetPath)
  207.         if videos:
  208.             self.signalChange()
  209.             return True
  210.         return False
  211.  
  212.     def splitItem(self):
  213.         """returns True if it ran signalChange()"""
  214.         if self.isContainerItem is not None:
  215.             return self.findNewChildren()
  216.         if not isinstance (self, FileItem) and (self.downloader is None or not self.downloader.isFinished()):
  217.             return False
  218.         filename_root = self.getFilename()
  219.         if os.path.isdir(filename_root):
  220.             videos = self.findChildVideos()
  221.             if len(videos) > 1:
  222.                 self.isContainerItem = True
  223.                 for video in videos:
  224.                     assert video.startswith(filename_root)
  225.                     offsetPath = video[len(filename_root):]
  226.                     if offsetPath[0] == '/':
  227.                         offsetPath = offsetPath[1:]
  228.                     FileItem (video, parent_id=self.id, offsetPath=offsetPath)
  229.             elif len(videos) == 1:
  230.                 self.isContainerItem = False
  231.                 for video in videos:
  232.                     assert video.startswith(filename_root)
  233.                     self.videoFilename = video[len(filename_root):]
  234.                     if self.videoFilename[0] in ('/', '\\'):
  235.                         self.videoFilename = self.videoFilename[1:]
  236.                     self.isVideo = True
  237.             else:
  238.                 if not self.getFeedURL().startswith ("dtv:directoryfeed"):
  239.                     target_dir = config.get(prefs.NON_VIDEO_DIRECTORY)
  240.                     if not filename_root.startswith(target_dir):
  241.                         if isinstance(self, FileItem):
  242.                             self.migrate (target_dir)
  243.                         else:
  244.                             self.downloader.migrate (target_dir)
  245.                 self.isContainerItem = False
  246.         else:
  247.             self.isContainerItem = False
  248.             self.videoFilename = FilenameType("")
  249.             self.isVideo = True
  250.         self.signalChange()
  251.         return True
  252.  
  253.     def removeFromPlaylists(self):
  254.         itemIDIndex = indexes.playlistsByItemID
  255.         view = views.playlists.filterWithIndex(itemIDIndex, self.getID())
  256.         for playlist in view:
  257.             playlist.removeItem(self)
  258.         view = views.playlistFolders.filterWithIndex(itemIDIndex, self.getID())
  259.         for playlist in view:
  260.             playlist.removeItem(self)
  261.  
  262.     def updateReleaseDate(self):
  263.         # This should be called whenever we get a new entry
  264.         try:
  265.             self.releaseDateObj = datetime(*self.getFirstVideoEnclosure().updated_parsed[0:7])
  266.         except:
  267.             try:
  268.                 self.releaseDateObj = datetime(*self.entry.updated_parsed[0:7])
  269.             except:
  270.                 self.releaseDateObj = datetime.min
  271.  
  272.     def checkConstraints(self):
  273.         if self.feed_id is not None:
  274.             try:
  275.                 obj = self.dd.getObjectByID(self.feed_id)
  276.             except ObjectNotFoundError:
  277.                 raise DatabaseConstraintError("my feed (%s) is not in database" % self.feed_id)
  278.             else:
  279.                 if not isinstance(obj, feed.Feed):
  280.                     msg = "feed_id points to a %s instance" % obj.__class__
  281.                     raise DatabaseConstraintError(msg)
  282.         if self.parent_id is not None:
  283.             try:
  284.                 obj = self.dd.getObjectByID(self.parent_id)
  285.             except ObjectNotFoundError:
  286.                 raise DatabaseConstraintError("my parent (%s) is not in database" % self.parent_id)
  287.             else:
  288.                 if not isinstance(obj, Item):
  289.                     msg = "parent_id points to a %s instance" % obj.__class__
  290.                     raise DatabaseConstraintError(msg)
  291.                 # If isContainerItem is None, we may be in the middle of building the children list.
  292.                 if obj.isContainerItem is not None and not obj.isContainerItem:
  293.                     msg = "parent_id is not a containerItem"
  294.                     raise DatabaseConstraintError(msg)
  295.         if self.parent_id is None and self.feed_id is None:
  296.             raise DatabaseConstraintError ("feed_id and parent_id both None")
  297.         if self.parent_id is not None and self.feed_id is not None:
  298.             raise DatabaseConstraintError ("feed_id and parent_id both not None")
  299.  
  300.     def signalChange(self, needsSave=True, needsUpdateXML=True):
  301.         self.expiring = None
  302.         try:
  303.             del self._state
  304.         except:
  305.             pass
  306.         try:
  307.             del self._size
  308.         except:
  309.             pass
  310.         if needsUpdateXML:
  311.             try:
  312.                 del self._itemXML
  313.             except:
  314.                 pass
  315.         DDBObject.signalChange(self, needsSave=needsSave)
  316.  
  317.     # Returns the rendered download-item template, hopefully from the cache
  318.     #
  319.     # viewName is the name of the view we're in. 
  320.     # view is the actual view object that we're in.
  321.     #
  322.     # Almost all of the search string is cached, but there are several pieces
  323.     # of data that must be generated on the fly:
  324.     #  * The name of the view, used for things like action:playNamedView
  325.     #  * The dragdesttype attribute -- it's based on the current selection
  326.     #  * The selected css class -- it's depends on whether the view that this
  327.     #     item is in is the view that's selected.  This matters when an item
  328.     #     is shown multiple times on a page, in different views.
  329.     #  * The channel name -- it's not displayed in the channel template.
  330.     def getItemXML(self, viewName):
  331.         try:
  332.             xml = self._itemXML
  333.         except AttributeError:
  334.             self._calcItemXML()
  335.             xml = self._itemXML
  336.         return xml.replace(self._XMLViewName, viewName)
  337.  
  338.     # Regenerates an expired item XML from the download-item template
  339.     # _XMLViewName is a random string we use for the name of the view
  340.     # _itemXML is the rendered XML
  341.     def _calcItemXML(self):
  342.         self._XMLViewName = "view%dview" % random.randint(9999999,99999999)
  343.         self._itemXML = template.fillStaticTemplate('download-item-inner', onlyBody=True, this=self, viewName = self._XMLViewName,templateState='unknown')
  344.         checkU(self._itemXML)
  345.  
  346.     #
  347.     # Returns True iff this item has never been viewed in the interface
  348.     # Note the difference between "viewed" and seen
  349.     def getViewed(self):
  350.         try:
  351.             # optimizing by trying the cached feed
  352.             return self._feed.lastViewed >= self.creationTime
  353.         except:
  354.             return self.creationTime <= self.getFeed().lastViewed 
  355.  
  356.     ##
  357.     # Returns the first video enclosure in the item
  358.     def getFirstVideoEnclosure(self):
  359.         try:
  360.             return self._firstVidEnc
  361.         except:
  362.             self._calcFirstEnc()
  363.             return self._firstVidEnc
  364.  
  365.     def _calcFirstEnc(self):
  366.         self._firstVidEnc = getFirstVideoEnclosure(self.entry)
  367.         
  368.  
  369.     ##
  370.     # Returns mime-type of the first video enclosure in the item
  371.     @returnsUnicode
  372.     def getFirstVideoEnclosureType(self):
  373.         enclosure = self.getFirstVideoEnclosure()
  374.         if enclosure and enclosure.has_key('type'):
  375.             return enclosure['type']
  376.         return None
  377.  
  378.  
  379.     ##
  380.     # Returns the URL associated with the first enclosure in the item
  381.     @returnsUnicode
  382.     def getURL(self):
  383.         self.confirmDBThread()
  384.         videoEnclosure = self.getFirstVideoEnclosure()
  385.         if videoEnclosure is not None and 'url' in videoEnclosure:
  386.             return quoteUnicodeURL(videoEnclosure['url'].replace('+', '%20'))
  387.         else:
  388.             return u''
  389.  
  390.     ##
  391.     # returns the title of the item quoted for inclusion in URLs
  392.     @returnsUnicode
  393.     def getQuotedURL(self):
  394.         return urllib.quote_plus(urllib.unquote(self.getURL().encode('ascii'))).decode('ascii')
  395.  
  396.     def hasSharableURL(self):
  397.         """Does this item have a URL that the user can share with others?
  398.  
  399.         This returns True when the item has a non-file URL.
  400.         """
  401.         url = self.getURL()
  402.         return url != u'' and not url.startswith(u"file:")
  403.  
  404.     ##
  405.     # Returns the feed this item came from
  406.     def getFeed(self):
  407.         try:
  408.             # optimizing by caching the feed
  409.             return self._feed
  410.         except:
  411.             if self.feed_id is not None:
  412.                 self._feed = self.dd.getObjectByID(self.feed_id)
  413.             elif self.parent_id is not None:
  414.                 self._feed = self.getParent().getFeed()
  415.             else:
  416.                 self._feed = None
  417.             return self._feed
  418.  
  419.     def getParent(self):
  420.         try:
  421.             return self._parent
  422.         except:
  423.             if self.parent_id is not None:
  424.                 self._parent = self.dd.getObjectByID(self.parent_id)
  425.             else:
  426.                 self._parent = self
  427.             return self._parent
  428.  
  429.     @returnsUnicode
  430.     def getFeedURL(self):
  431.         return self.getFeed().getURL()
  432.  
  433.     def feedExists(self):
  434.         return self.feed_id and self.dd.idExists(self.feed_id)
  435.  
  436.     def getChildren(self):
  437.         if self.isContainerItem:
  438.             return views.items.filterWithIndex(indexes.itemsByParent, self.id)
  439.         else:
  440.             raise ValueError("%s is not a container item" % self)
  441.  
  442.     ##
  443.     # Moves this item to another feed.
  444.     def setFeed(self, feed_id):
  445.         self.feed_id = feed_id
  446.         del self._feed
  447.         if self.isContainerItem:
  448.             for item in self.getChildren():
  449.                 del item._feed
  450.                 item.signalChange()
  451.         self.signalChange()
  452.  
  453.     def executeExpire(self):
  454.         self.confirmDBThread()
  455.         self.removeFromPlaylists()
  456.         UandA = self.getUandA()
  457.         if not self.isExternal():
  458.             self.deleteFiles()
  459.         self.expired = True
  460.         if self.isContainerItem:
  461.             for item in self.getChildren():
  462.                 item.remove()
  463.         self.isContainerItem = None
  464.         self.isVideo = False
  465.         self.videoFilename = FilenameType("")
  466.         self.seen = self.keep = self.pendingManualDL = False
  467.         self.watchedTime = None
  468.         self.duration = None
  469.         if self.screenshot:
  470.             try:
  471.                 os.remove(self.screenshot)
  472.             except:
  473.                 pass
  474.         # This should be done even if screenshot = ""
  475.         self.screenshot = None
  476.         if self.isExternal():
  477.             if self.isDownloaded():
  478.                 new_item = FileItem (self.getVideoFilename(), feed_id=self.feed_id, parent_id=self.parent_id, deleted=True)
  479.                 if self.downloader is not None:
  480.                     self.downloader.setDeleteFiles(False)
  481.             self.remove()
  482.         else:
  483.             self.signalChange()
  484.  
  485.     ##
  486.     # Marks this item as expired
  487.     def expire(self):
  488.         title = _("Removing %s") % os.path.basename(self.getTitle())
  489.         if self.isExternal():
  490.             if self.isContainerItem:
  491.                 description = _("""\
  492. Would you like to delete this folder and all of its videos or just remove \
  493. its entry from the Library?""")
  494.                 button = dialogs.BUTTON_DELETE_FILES
  495.             else:
  496.                 if self.isDownloaded():
  497.                     description = _("""\
  498. Would you like to delete this file or just remove its entry from the \
  499. Library?""")
  500.                     button = dialogs.BUTTON_DELETE_FILE
  501.                 else:
  502.                     self.executeExpire()
  503.                     return
  504.             d = dialogs.ThreeChoiceDialog(title, description,
  505.                     dialogs.BUTTON_REMOVE_ENTRY, button,
  506.                     dialogs.BUTTON_CANCEL)
  507.             def callback(dialog):
  508.                 if not self.idExists():
  509.                     return
  510.                 if dialog.choice == button:
  511.                     self.deleteFiles()
  512.                 if dialog.choice in (button, dialogs.BUTTON_REMOVE_ENTRY):
  513.                     self.executeExpire()
  514.     
  515.             d.run(callback)
  516.         elif self.isContainerItem:
  517.             description = _("""\
  518. This item is a folder.  When you remove a folder, any items inside that \
  519. folder will be deleted.""")
  520.             d = dialogs.ChoiceDialog(title, description,
  521.                                      dialogs.BUTTON_DELETE_FILES,
  522.                                      dialogs.BUTTON_CANCEL)
  523.             def callback(dialog):
  524.                 if self.idExists() and dialog.choice == dialogs.BUTTON_DELETE_FILES:
  525.                     self.executeExpire()
  526.             d.run(callback)
  527.         else:
  528.             self.executeExpire()
  529.  
  530.     def stopUpload (self):
  531.         if self.downloader:
  532.             self.downloader.stopUpload()
  533.  
  534.     def startUpload (self):
  535.         if self.downloader:
  536.             self.downloader.startUpload()
  537.  
  538.     @returnsUnicode
  539.     def getString(self, when):
  540.         """Get the expiration time a string to display to the user."""
  541.         offset = when - datetime.now()
  542.         if offset.days > 0:
  543.             result = _("%d days") % offset.days
  544.         elif offset.seconds > 3600:
  545.             result = _("%d hours") % (ceil(offset.seconds/3600.0))
  546.         else:
  547.             result = _("%d minutes") % (ceil(offset.seconds/60.0))
  548.         return result
  549.  
  550.     @returnsUnicode
  551.     def getExpirationString(self):
  552.         """Get the expiration time a string to display to the user."""
  553.         expireTime = self.getExpirationTime()
  554.         if expireTime is None:
  555.             return u""
  556.         else:
  557.             return _('Expires in %s') % self.getString (expireTime)
  558.  
  559.     @returnsUnicode
  560.     def getPausedString(self):
  561.         """Get the expiration time a string to display to the user."""
  562.         retryTime = None
  563.         if self.downloader:
  564.             if self.downloader.getState() == u'offline':
  565.                 retryTime = self.downloader.status['retryTime']
  566.                 if retryTime is None:
  567.                     return ""
  568.                 else:
  569.                     return _('Will retry in %s') % self.getString (retryTime)
  570.             else:
  571.                 return _('Paused')
  572.         else:
  573.             return u""
  574.  
  575.     @returnsUnicode
  576.     def getDragType(self):
  577.         if self.isDownloaded():
  578.             return u'downloadeditem'
  579.         else:
  580.             return u'item'
  581.  
  582.     @returnsUnicode
  583.     def getEmblemCSSClass(self):
  584.         if self.getState() == u'newly-downloaded':
  585.             return u'newly-downloaded'
  586.         elif self.getState() == u'new':
  587.             return u'new'
  588.         else:
  589.             return u''
  590.  
  591.     @returnsUnicode
  592.     def getEmblemCSSString(self):
  593.         if self.getState() == u'newly-downloaded':
  594.             return u'UNWATCHED'
  595.         elif self.getState() == u'new':
  596.             return u'NEW'
  597.         else:
  598.             return u''
  599.  
  600.     def getUandA(self):
  601.         """Get whether this item is new, or newly-downloaded, or neither."""
  602.         state = self.getState()
  603.         if state == u'new':
  604.             return (0, 1)
  605.         elif state == u'newly-downloaded':
  606.             return (1, 0)
  607.         else:
  608.             return (0, 0)
  609.  
  610.     def getExpirationTime(self):
  611.         """Get the time when this item will expire. 
  612.         Returns a datetime object,  or None if it doesn't expire.
  613.         """
  614.  
  615.         self.confirmDBThread()
  616.         if self.getWatchedTime() is None or not self.isDownloaded():
  617.             return None
  618.         ufeed = self.getFeed()
  619.         if ufeed.expire == u'never' or (ufeed.expire == u'system'
  620.                 and config.get(prefs.EXPIRE_AFTER_X_DAYS) <= 0):
  621.             return None
  622.         else:
  623.             if ufeed.expire == u"feed":
  624.                 expireTime = ufeed.expireTime
  625.             elif ufeed.expire == u"system":
  626.                 expireTime = timedelta(days=config.get(prefs.EXPIRE_AFTER_X_DAYS))
  627.             return self.getWatchedTime() + expireTime
  628.  
  629.     def getWatchedTime(self):
  630.         if not self.getSeen():
  631.             return None
  632.         if self.isContainerItem and self.watchedTime == None:
  633.             self.watchedTime = datetime.min
  634.             for item in self.getChildren():
  635.                 childTime = item.getWatchedTime()
  636.                 if childTime is None:
  637.                     self.watchedTime = None
  638.                     return None
  639.                 if childTime > self.watchedTime:
  640.                     self.watchedTime = childTime
  641.             self.signalChange()
  642.         return self.watchedTime
  643.  
  644.     def getExpiring(self):
  645.         if self.expiring is None:
  646.             if not self.getSeen():
  647.                 self.expiring = False
  648.             else:
  649.                 ufeed = self.getFeed()
  650.                 if (self.keep or ufeed.expire == u'never' or 
  651.                         (ufeed.expire == u'system' and
  652.                             config.get(prefs.EXPIRE_AFTER_X_DAYS) <= 0)):
  653.                     self.expiring = False
  654.                 else:
  655.                     self.expiring = True
  656.         return self.expiring
  657.  
  658.     ##
  659.     # returns true iff video has been seen
  660.     # Note the difference between "viewed" and "seen"
  661.     def getSeen(self):
  662.         self.confirmDBThread()
  663.         if self.isContainerItem:
  664.             if self.childrenSeen is None:
  665.                 self.childrenSeen = True
  666.                 for item in self.getChildren():
  667.                     if not item.seen:
  668.                         self.childrenSeen = False
  669.                         break
  670.             return self.childrenSeen
  671.         else:
  672.             return self.seen
  673.  
  674.     ##
  675.     # Marks the item as seen
  676.     def markItemSeen(self):
  677.         self.confirmDBThread()
  678.         if self.seen == False:
  679.             self.seen = True
  680.             if self.watchedTime is None:
  681.                 self.watchedTime = datetime.now()
  682.             self.clearParentsChildrenSeen()
  683.             self.signalChange()
  684.  
  685.     def clearParentsChildrenSeen(self):
  686.         if self.parent_id:
  687.             parent = self.getParent()
  688.             parent.childrenSeen = None
  689.             parent.signalChange()
  690.  
  691.     def markItemUnseen(self):
  692.         self.confirmDBThread()
  693.         if self.isContainerItem:
  694.             self.childrenSeen = False
  695.             for item in self.getChildren():
  696.                 item.seen = False
  697.                 item.signalChange()
  698.             self.signalChange()
  699.         else:
  700.             if self.seen == False:
  701.                 return
  702.             self.seen = False
  703.             self.watchedTime = None
  704.             self.clearParentsChildrenSeen()
  705.             self.signalChange()
  706.  
  707.     @returnsUnicode
  708.     def getRSSID(self):
  709.         self.confirmDBThread()
  710.         return self.entry["id"]
  711.  
  712.     def removeRSSID(self):
  713.         self.confirmDBThread()
  714.         if 'id' in self.entry:
  715.             del self.entry['id']
  716.             self.signalChange()
  717.  
  718.     def setAutoDownloaded(self,autodl = True):
  719.         self.confirmDBThread()
  720.         if autodl != self.autoDownloaded:
  721.             self.autoDownloaded = autodl
  722.             self.signalChange()
  723.  
  724.     @eventloop.asIdle
  725.     def setResumeTime(self, position):
  726.         if not self.idExists():
  727.             return
  728.         position = int(position)
  729.         if self.resumeTime != position:
  730.             self.resumeTime = position
  731.             self.signalChange()
  732.  
  733.     @returnsUnicode
  734.     def getPendingReason(self):
  735.         self.confirmDBThread()
  736.         return self.pendingReason
  737.  
  738.     ##
  739.     # Returns true iff item was auto downloaded
  740.     def getAutoDownloaded(self):
  741.         self.confirmDBThread()
  742.         return self.autoDownloaded
  743.  
  744.     ##
  745.     # Returns the linkNumber
  746.     def getLinkNumber(self):
  747.         self.confirmDBThread()
  748.         return self.linkNumber
  749.  
  750.     ##
  751.     # Starts downloading the item
  752.     def download(self,autodl=False):
  753.         autodler.resumeDownloader()
  754.         self.confirmDBThread()
  755.         manualDownloadCount = views.manualDownloads.len()
  756.         self.expired = self.keep = self.seen = False
  757.  
  758.         if ((not autodl) and 
  759.                 manualDownloadCount >= config.get(prefs.MAX_MANUAL_DOWNLOADS)):
  760.             self.pendingManualDL = True
  761.             self.pendingReason = u"queued for download" # FIXME:
  762.                                                         # Should this
  763.                                                         # be
  764.                                                         # translated --NN
  765.             self.signalChange()
  766.             return
  767.         else:
  768.             self.setAutoDownloaded(autodl)
  769.             self.pendingManualDL = False
  770.  
  771.         if self.downloader is None:
  772.             self.downloader = downloader.getDownloader(self)
  773.         if self.downloader is not None:
  774.             self.downloader.setChannelName (platformutils.unicodeToFilename(self.getChannelTitle(True)))
  775.             if self.downloader.isFinished():
  776.                 self.onDownloadFinished()
  777.             else:
  778.                 self.downloader.start()
  779.         self.signalChange()
  780.  
  781.     def pause(self):
  782.         if self.downloader:
  783.             self.downloader.pause()
  784.  
  785.     def resume(self):
  786.         self.download(self.getAutoDownloaded())
  787.  
  788.     def isPendingManualDownload(self):
  789.         self.confirmDBThread()
  790.         return self.pendingManualDL
  791.  
  792.     def isEligibleForAutoDownload(self):
  793.         self.confirmDBThread()
  794.         if self.getState() not in (u'new', u'not-downloaded'):
  795.             return False
  796.         if self.downloader and self.downloader.getState() in (u'failed',
  797.                 u'stopped', u'paused'):
  798.             return False
  799.         ufeed = self.getFeed()
  800.         if ufeed.getEverything:
  801.             return True
  802.         return self.eligibleForAutoDownload
  803.  
  804.     def isPendingAutoDownload(self):
  805.         return (self.getFeed().isAutoDownloadable() and
  806.                 self.isEligibleForAutoDownload())
  807.  
  808.     def isFailedDownload(self):
  809.         return self.downloader and self.downloader.getState() == u'failed'
  810.  
  811.     ##
  812.     # Returns a link to the thumbnail of the video
  813.     @returnsUnicode
  814.     def getThumbnailURL(self):
  815.         self.confirmDBThread()
  816.         # Try to get the thumbnail specific to the video enclosure
  817.         videoEnclosure = self.getFirstVideoEnclosure()
  818.         if videoEnclosure is not None:
  819.             try:
  820.                 return videoEnclosure["thumbnail"]["url"].decode("ascii","replace")
  821.             except:
  822.                 pass 
  823.         # Try to get any enclosure thumbnail
  824.         for enclosure in self.entry.enclosures:
  825.             try:
  826.                 return enclosure["thumbnail"]["url"].decode('ascii','replace')
  827.             except KeyError:
  828.                 pass
  829.         # Try to get the thumbnail for our entry
  830.         try:
  831.             return self.entry["thumbnail"]["url"].decode('ascii','replace')
  832.         except:
  833.             return None
  834.  
  835.     # When changing this function, change feed.iconChanged to signal the right set of items
  836.     @returnsUnicode
  837.     def getThumbnail (self):
  838.         self.confirmDBThread()
  839.         if self.showMoreInfo:
  840.             width, height = Item.BIG_ICON_SIZE
  841.         else:
  842.             width, height = Item.SMALL_ICON_SIZE
  843.         if self.iconCache.isValid():
  844.             path = self.iconCache.getResizedFilename(width, height)
  845.             return resources.absoluteUrl(path)
  846.         elif self.screenshot:
  847.             path = self.getResizedScreenshot(width, height)
  848.             return resources.absoluteUrl(path)
  849.         elif self.isContainerItem:
  850.             return resources.url(u"images/container-icon.png")
  851.         else:
  852.             feedThumbnail = self.getFeed().getItemThumbnail(width, height)
  853.             if feedThumbnail is not None:
  854.                 return feedThumbnail
  855.             elif self.showMoreInfo:
  856.                 return resources.url(u"images/thumb-more-info.png")
  857.             else: 
  858.                 return resources.url(u"images/thumb.png")
  859.  
  860.     ##
  861.     # returns the title of the item
  862.     @returnsUnicode
  863.     def getTitle(self):
  864.         try:
  865.             return self.entry.title
  866.         except:
  867.             try:
  868.                 enclosure = self.getFirstVideoEnclosure()
  869.                 return enclosure["url"].decode('ascii','replace')
  870.             except:
  871.                 return u""
  872.  
  873.     ##
  874.     # returns the title of the item quoted for inclusion in URLs
  875.     @returnsUnicode
  876.     def getQuotedTitle(self):
  877.         return urllib.quote_plus(self.getTitle().encode('utf8')).decode('ascii', 'replace')
  878.  
  879.     def setChannelTitle(self, title):
  880.         checkU(title)
  881.         self.channelTitle = title
  882.  
  883.     @returnsUnicode
  884.     def getChannelTitle(self, allowSearchFeedTitle=False):
  885.         implClass = self.getFeed().actualFeed.__class__
  886.         if implClass in (feed.RSSFeedImpl, feed.ScraperFeedImpl):
  887.             return self.getFeed().getTitle()
  888.         elif implClass == feed.SearchFeedImpl and allowSearchFeedTitle:
  889.             return searchengines.getLastEngineTitle()
  890.         elif self.channelTitle:
  891.             return self.channelTitle
  892.         else:
  893.             return u''
  894.  
  895.     ##
  896.     # Returns the raw description of the video (unicode)
  897.     @returnsUnicode
  898.     def getRawDescription(self):
  899.         self.confirmDBThread()
  900.         try:
  901.             enclosure = self.getFirstVideoEnclosure()
  902.             return enclosure["text"]
  903.         except:
  904.             try:
  905.                 return self.entry.description
  906.             except:
  907.                 return u''
  908.  
  909.     ##
  910.     # Returns valid XHTML containing a description of the video (str)
  911.     @returnsUnicode
  912.     def getDescription(self):
  913.         rawDescription = self.getRawDescription()
  914.         try:
  915.             purifiedDescription = adscraper.purify(rawDescription)
  916.             return xhtmlify (u'<span>%s</span>' % (unescape(purifiedDescription),), filterFontTags=True)
  917.         except:
  918.             try:
  919.                 return xhtmlify (u'<span>%s</span>' % (unescape(rawDescription),))
  920.             except:
  921.                 return u'<span />'
  922.  
  923.     ##
  924.     # Returns valid XHTML containing the ad (str)
  925.     def getAd(self):
  926.         rawDescription = self.getRawDescription()
  927.         try:
  928.             rawAd = adscraper.scrape(rawDescription)
  929.             return xhtmlify (u'<span>%s</span>' % (unescape(rawAd),))
  930.         except:
  931.             return u'<span />'
  932.  
  933.     def looksLikeTorrent(self):
  934.         """Returns true if we think this item is a torrent.  (For items that
  935.         haven't been downloaded this uses the file extension which isn't
  936.         totally reliable).
  937.         """
  938.  
  939.         if self.downloader is not None:
  940.             return self.downloader.getType() == u'bittorrent'
  941.         else:
  942.             return self.getURL().endswith(u'.torrent')
  943.  
  944.     ##
  945.     # Returns formatted XHTML with release date, duration, format, and size
  946.     @returnsUnicode
  947.     def getDetails(self):
  948.         details = []
  949.         reldate = self.getReleaseDate()
  950.         format = self.getFormat()
  951.         size = self.getSizeForDisplay()
  952.         link = self.getLink()
  953.  
  954.         if self.isContainerItem:
  955.             children = self.getChildren()
  956.             details.append(u'<span class="details-count">%s items</span>' % len(children))
  957.         if len(reldate) > 0:
  958.             details.append(u'<span class="details-date">%s</span>' % escape(reldate))
  959.         if len(size) > 0:
  960.             details.append(u'<span class="details-size">%s</span>' % escape(size))
  961.         if len(format) > 0:
  962.             details.append(u'<span class="details-format">%s</span>' % escape(format))
  963.         if self.looksLikeTorrent():
  964.             details.append(u'<span class="details-torrent">%s</span>' % _("TORRENT"))
  965.         if len(link) > 0 and link != self.getURL():
  966.             details.append(u'<a class="details-link" href="%s">%s</span>' % (quoteattr(link), _("WEB PAGE")))
  967.         out = u'<BR>'.join(details)
  968.         return out
  969.  
  970.     def isTransferring(self):
  971.         return self.downloader and self.downloader.getState() in (u'uploading', u'downloading')
  972.  
  973.     def getDownloadDetails(self):
  974.         status = self.downloader.status
  975.         details = [
  976.             (_('Total Down:'), formatSizeForDetails(status.get('currentSize', 0))),
  977.         ]
  978.         if status.get("reasonFailed"):
  979.             details.append((_('Error:'), status['reasonFailed']))
  980.         return details
  981.  
  982.     def getTorrentDetails(self):
  983.         status = self.downloader.status
  984.         retval = []
  985.         seeders = status.get('seeders', -1)
  986.         leechers = status.get('leechers', -1)
  987.         if seeders != -1:
  988.             retval.append((_('Seeders:'), seeders))
  989.         if leechers != -1:
  990.             retval.append((_('Leechers:'), leechers))
  991.         retval.extend ([
  992.             (_('Down Rate:'), formatRateForDetails(status.get('rate', 0))),
  993.             (_('Down Total:'), formatSizeForDetails(
  994.                 status.get('currentSize', 0))),
  995.             (_('Up Rate:'), formatRateForDetails(status.get('upRate', 0))),
  996.             (_('Up Total:'), formatSizeForDetails(status.get('uploaded', 0))),
  997.         ])
  998.  
  999.         return retval
  1000.  
  1001.     def getItemDetails(self):
  1002.         rv = []
  1003.         
  1004.         link = self.getLink()
  1005.         if link:
  1006.             rv.append((_('Web page:'), util.makeAnchor(_('permalink'), link)))
  1007.  
  1008.         url = self.getURL()
  1009.         if url and not url.startswith("file:"):
  1010.             rv.append((_('File link:'), util.makeAnchor(_('direct link to file'),
  1011.                                               url)))
  1012.         rv.append((_('File type:'), self.getFormat()))
  1013.  
  1014.         if self.getLicence():
  1015.             # check the license to see if it's a url by seeing if it has a 
  1016.             # protocol
  1017.             if urlparse.urlparse(self.getLicence())[0]:
  1018.                 ln = license.license_name(self.getLicence())
  1019.                 rv.append((_('License:'), util.makeAnchor(ln,
  1020.                                                           self.getLicence())))
  1021.             else:
  1022.                 rv.append((_('License:'), _('see permalink')))
  1023.         else:
  1024.             rv.append((_('License:'), _('see permalink')))
  1025.  
  1026.         if self.isDownloaded():
  1027.             basename = os.path.basename(self.getFilename())
  1028.             basename = util.clampText(basename, 40)
  1029.             linkEventURL = u'revealItem?item=%d' % self.getID()
  1030.             if self.isContainerItem:
  1031.                 label = _("REVEAL LOCAL FOLDER")
  1032.             else:
  1033.                 label = _("REVEAL LOCAL FILE")
  1034.             link = util.makeEventURL(label, linkEventURL)
  1035.             rv.append((_('Filename:'), u"%s<BR />%s" % (platformutils.filenameToUnicode(basename), link)))
  1036.         return rv
  1037.  
  1038.  
  1039.     def getTorrentDetailsFinished(self):
  1040.         status = self.downloader.status
  1041.         return [
  1042.             (_('Down Total'), formatSizeForDetails(
  1043.                 status.get('currentSize', 0))),
  1044.             (_('Up Total'), formatSizeForDetails(status.get('uploaded', 0))),
  1045.         ]
  1046.  
  1047.     def makeMoreInfoTable(self, title, moreInfoData):
  1048.         lines = []
  1049.         lines.append(u'<h3>%s</h3>' % title)
  1050.         lines.append(u'<table cellpadding="0" cellspacing="0">')
  1051.         for label, text in moreInfoData:
  1052.             lines.append(u'<tr><td class="label">%s</td>'
  1053.                     u'<td class="value">%s</td></tr>' % (label, text))
  1054.         lines.append(u'</table>')
  1055.         return u'\n'.join(lines)
  1056.  
  1057.     ## 
  1058.     # Returns formatted XHTML with download info
  1059.     @returnsUnicode
  1060.     def getMoreInfo(self):
  1061.         details = [
  1062.             self.makeMoreInfoTable(_('Item Details'), self.getItemDetails()),
  1063.         ]
  1064.         # helper function to keep things from getting too verbose below
  1065.         def addTable(label, data):
  1066.             details.append(self.makeMoreInfoTable(label, data))
  1067.         if self.looksLikeTorrent():
  1068.             if self.isTransferring():
  1069.                 addTable(_('Torrent Details'), self.getTorrentDetails())
  1070.             elif self.downloader and self.downloader.isFinished():
  1071.                 addTable(_('Torrent Details <i>stopped</i>'),
  1072.                         self.getTorrentDetailsFinished())
  1073.         elif ((self.getState() == u'downloading' and not self.pendingManualDL)
  1074.                 or self.isFailedDownload()):
  1075.             addTable(_('Download Details'), self.getDownloadDetails())
  1076.         return u'\n'.join(details)
  1077.  
  1078.  
  1079.     ##
  1080.     # Stops downloading the item
  1081.     def deleteFiles(self):
  1082.         self.confirmDBThread()
  1083.         if self.downloader is not None:
  1084.             self.downloader.removeItem(self)
  1085.             self.downloader = None
  1086.             self.signalChange()
  1087.  
  1088.     def getState(self):
  1089.         """Get the state of this item.  The state will be on of the following:
  1090.  
  1091.         * new -- User has never seen this item
  1092.         * not-downloaded -- User has seen the item, but not downloaded it
  1093.         * downloading -- Item is currently downloading
  1094.         * newly-downloaded -- Item has been downoladed, but not played
  1095.         * expiring -- Item has been played and is set to expire
  1096.         * saved -- Item has been played and has been saved
  1097.         * expired -- Item has expired.
  1098.  
  1099.         Uses caching to prevent recalculating state over and over
  1100.         """
  1101.         try:
  1102.             return self._state
  1103.         except AttributeError:
  1104.             self._calcState()
  1105.             return self._state
  1106.  
  1107.     # Recalculate the state of an item after a change
  1108.     @returnsUnicode
  1109.     def _calcState(self):
  1110.         self.confirmDBThread()
  1111.         # FIXME, 'failed', and 'paused' should get download icons.  The user
  1112.         # should be able to restart or cancel them (put them into the stopped
  1113.         # state).
  1114.         if (self.downloader is None  or 
  1115.                 self.downloader.getState() in (u'failed', u'stopped')):
  1116.             if self.pendingManualDL:
  1117.                 self._state = u'downloading'
  1118.             elif self.expired:
  1119.                 self._state = u'expired'
  1120.             elif (self.getViewed() or
  1121.                     (self.downloader and
  1122.                         self.downloader.getState() in (u'failed', u'stopped'))):
  1123.                 self._state = u'not-downloaded'
  1124.             else:
  1125.                 self._state = u'new'
  1126.         elif self.downloader.getState() in (u'offline', u'paused'):
  1127.             if self.pendingManualDL:
  1128.                 self._state = u'downloading'
  1129.             else:
  1130.                 self._state = u'paused'
  1131.         elif not self.downloader.isFinished():
  1132.             self._state = u'downloading'
  1133.         elif not self.getSeen():
  1134.             self._state = u'newly-downloaded'
  1135.         elif self.getExpiring():
  1136.             self._state = u'expiring'
  1137.         else:
  1138.             self._state = u'saved'
  1139.  
  1140.     @returnsUnicode    
  1141.     def getChannelCategory(self):
  1142.         """Get the category to use for the channel template.  
  1143.         
  1144.         This method is similar to getState(), but has some subtle differences.
  1145.         getState() is used by the download-item template and is usually more
  1146.         useful to determine what's actually happening with an item.
  1147.         getChannelCategory() is used by by the channel template to figure out
  1148.         which heading to put an item under.
  1149.  
  1150.         * downloading and not-downloaded are grouped together as
  1151.           not-downloaded
  1152.         * Newly downloaded and downloading items are always new if
  1153.           their feed hasn't been marked as viewed after the item's pub
  1154.           date.  This is so that when a user gets a list of items and
  1155.           starts downloading them, the list doesn't reorder itself.
  1156.           Once they start watching them, then it reorders itself.
  1157.         """
  1158.  
  1159.         self.confirmDBThread()
  1160.         if self.downloader is None or not self.downloader.isFinished():
  1161.             if not self.getViewed():
  1162.                 return u'new'
  1163.             if self.expired:
  1164.                 return u'expired'
  1165.             else:
  1166.                 return u'not-downloaded'
  1167.         elif not self.getSeen():
  1168.             if not self.getViewed():
  1169.                 return u'new'
  1170.             return u'newly-downloaded'
  1171.         elif self.getExpiring():
  1172.             return u'expiring'
  1173.         else:
  1174.             return u'saved'
  1175.  
  1176.     def isDownloadable(self):
  1177.         return self.getState() in (u'new', u'not-downloaded', u'expired')
  1178.  
  1179.     def isDownloaded(self):
  1180.         return self.getState() in (u"newly-downloaded", u"expiring", u"saved")
  1181.  
  1182.     def showSaveButton(self):
  1183.         return self.getState() in (u'newly-downloaded', u'expiring') and not self.keep
  1184.  
  1185.     def showSaved(self):
  1186.         return self.getState() in (u'saved',) or (self.getState() in (u'newly-downloaded', u'expiring') and self.keep)
  1187.  
  1188.     def showTrashButton(self):
  1189.         return self.isDownloaded() or (self.getFeedURL() == u'dtv:manualFeed'
  1190.                 and self.getState() not in (u'downloading', u'paused'))
  1191.  
  1192.     @returnsUnicode
  1193.     def getFailureReason(self):
  1194.         self.confirmDBThread()
  1195.         if self.downloader is not None:
  1196.             return self.downloader.getShortReasonFailed()
  1197.         else:
  1198.             return u""
  1199.     
  1200.     ##
  1201.     # Returns the size of the item to be displayed.
  1202.     def getSizeForDisplay(self):
  1203.         return util.formatSizeForUser(self.getSize())
  1204.  
  1205.     def getSize(self):
  1206.         if not hasattr(self, "_size"):
  1207.             self._size = self._getSize()
  1208.         return self._size
  1209.  
  1210.     ##
  1211.     # Returns the size of the item. We use the following methods to get the
  1212.     # size:
  1213.     #
  1214.     # Physical size of a downloaded file
  1215.     # HTTP content-length
  1216.     # RSS enclosure tag value.
  1217.     def _getSize(self):
  1218.         fname = self.getFilename()
  1219.         if self.isDownloaded():
  1220.             try:
  1221.                 return util.getsize(fname)
  1222.             except OSError:
  1223.                 return 0
  1224.         elif self.downloader is not None:
  1225.             return self.downloader.getTotalSize()
  1226.         else:
  1227.             try:
  1228.                 return int(self.getFirstVideoEnclosure()['length'])
  1229.             except:
  1230.                 return 0
  1231.  
  1232.     ##
  1233.     # returns status of the download in plain text
  1234.     @returnsUnicode
  1235.     def getCurrentSize(self):
  1236.         if self.downloader is not None:
  1237.             size = self.downloader.getCurrentSize()
  1238.         else:
  1239.             size = 0
  1240.         return util.formatSizeForUser(size)
  1241.  
  1242.     ##
  1243.     # Returns the download progress in absolute percentage [0.0 - 100.0].
  1244.     def downloadProgress(self):
  1245.         progress = 0
  1246.         self.confirmDBThread()
  1247.         if self.downloader is None:
  1248.             return 0
  1249.         else:
  1250.             size = self.downloader.getTotalSize()
  1251.             dled = self.downloader.getCurrentSize()
  1252.             if size == 0:
  1253.                 return 0
  1254.             else:
  1255.                 return (100.0*dled) / size
  1256.  
  1257.     def gotContentLength(self):
  1258.         if self.downloader is None:
  1259.             return False
  1260.         else:
  1261.             return self.downloader.getTotalSize() != -1
  1262.  
  1263.     ##
  1264.     # Returns the width of the progress bar corresponding to the current
  1265.     # download progress. This doesn't really belong here and even forces
  1266.     # to use a hardcoded constant, but the templating system doesn't 
  1267.     # really leave any other choice.
  1268.     def downloadProgressWidth(self):
  1269.         fullWidth = 112  # width of resource:channelview-progressbar-bg.png
  1270.         progress = self.downloadProgress() / 100.0
  1271.         if progress == 0:
  1272.             return 0
  1273.         return int(progress * fullWidth)
  1274.  
  1275.     ##
  1276.     # Returns string containing three digit percent finished
  1277.     # "000" through "100".
  1278.     @returnsUnicode
  1279.     def threeDigitPercentDone(self):
  1280.         return u'%03d' % int(self.downloadProgress())
  1281.  
  1282.     def downloadInProgress(self):
  1283.         return self.downloader is not None and self.downloader.getETA() != 0
  1284.  
  1285.     ##
  1286.     # Returns string with estimate time until download completes
  1287.     @returnsUnicode
  1288.     def downloadETA(self):
  1289.         if self.downloader is not None:
  1290.             totalSecs = self.downloader.getETA()
  1291.             if totalSecs <= 0:
  1292.                 return _('downloading...')
  1293.         else:
  1294.             totalSecs = 0
  1295.         mins, secs = divmod(totalSecs, 60)
  1296.         hours, mins = divmod(mins, 60)
  1297.         if hours > 0:
  1298.             time = u"%d:%02d:%02d" % (hours, mins, secs)
  1299.             return _("%s remaining") % time
  1300.         else:
  1301.             time = u"%d:%02d" % (mins, secs)
  1302.             return _("%s remaining") % time
  1303.  
  1304.     @returnsUnicode
  1305.     def getStartupActivity(self):
  1306.         if self.pendingManualDL:
  1307.             return self.pendingReason
  1308.         elif self.downloader:
  1309.             return self.downloader.getStartupActivity()
  1310.         else:
  1311.             return _("starting up...")
  1312.  
  1313.     ##
  1314.     # Returns the download rate
  1315.     @returnsUnicode
  1316.     def downloadRate(self):
  1317.         rate = 0
  1318.         unit = u"KB/s"
  1319.         if self.downloader is not None:
  1320.             rate = self.downloader.getRate()
  1321.         else:
  1322.             rate = 0
  1323.         rate /= 1024
  1324.         if rate > 1024:
  1325.             rate /= 1024
  1326.             unit = u"MB/s"
  1327.         if rate > 1024:
  1328.             rate /= 1024
  1329.             unit = u"GB/s"
  1330.             
  1331.         return u"%d%s" % (rate, unit)
  1332.  
  1333.     ##
  1334.     # Returns the published date of the item
  1335.     @returnsUnicode
  1336.     def getPubDate(self):
  1337.         return getReleaseDate()
  1338.     
  1339.     ##
  1340.     # Returns the published date of the item as a datetime object
  1341.     def getPubDateParsed(self):
  1342.         return self.getReleaseDateObj()
  1343.  
  1344.     ##
  1345.     # returns the date this video was released or when it was published
  1346.     @returnsUnicode
  1347.     def getReleaseDate(self):
  1348.         try:
  1349.             return self.getReleaseDateObj().strftime("%b %d %Y").decode(_charset)
  1350.         except:
  1351.             return u""
  1352.  
  1353.     ##
  1354.     # returns the date this video was released or when it was published
  1355.     def getReleaseDateObj(self):
  1356.         return self.releaseDateObj
  1357.  
  1358.     ##
  1359.     # returns the length of the video in seconds
  1360.     def getDurationValue(self):
  1361.         secs = 0
  1362.         if self.duration not in (-1, None):
  1363.             secs = self.duration / 1000
  1364.         return secs
  1365.  
  1366.     ##
  1367.     # returns string with the play length of the video
  1368.     @returnsUnicode
  1369.     def getDuration(self, emptyIfZero=True):
  1370.         secs = self.getDurationValue()
  1371.         if secs == 0:
  1372.             if emptyIfZero:
  1373.                 return u""
  1374.             else:
  1375.                 return "n/a"
  1376.         return u"%02d:%02d" % (secs/60, secs % 60)
  1377.  
  1378.     ##
  1379.     # returns string with the format of the video
  1380.     KNOWN_MIME_TYPES = (u'audio', u'video')
  1381.     KNOWN_MIME_SUBTYPES = (u'mov', u'wmv', u'mp4', u'mp3', u'mpg', u'mpeg', u'avi', u'x-flv', u'x-msvideo', u'm4v', u'mkv', u'm2v')
  1382.     MIME_SUBSITUTIONS = {
  1383.         u'QUICKTIME': u'MOV',
  1384.     }
  1385.     @returnsUnicode
  1386.     def getFormat(self, emptyForUnknown=True):
  1387.         if self.looksLikeTorrent():
  1388.             return u'.torrent'
  1389.         try:
  1390.             enclosure = self.entry['enclosures'][0]
  1391.             try:
  1392.                 extension = enclosure['url'].split('.')[-1].lower().decode('ascii','replace')
  1393.             except:
  1394.                 extension == u''
  1395.             # Hack for mp3s, "mpeg audio" isn't clear enough
  1396.             if extension.lower() == u'mp3':
  1397.                 return u'.mp3'
  1398.             if enclosure.has_key('type') and len(enclosure['type']) > 0:
  1399.                 mtype, subtype = enclosure['type'].decode('ascii','replace').split('/')
  1400.                 mtype = mtype.lower()
  1401.                 if mtype in self.KNOWN_MIME_TYPES:
  1402.                     format = subtype.split(';')[0].upper()
  1403.                     if mtype == u'audio':
  1404.                         format += u' AUDIO'
  1405.                     if format.startswith(u'X-'):
  1406.                         format = format[2:]
  1407.                     return u'.%s' % self.MIME_SUBSITUTIONS.get(format, format).lower()
  1408.             if extension in self.KNOWN_MIME_SUBTYPES:
  1409.                 return u'.%s' % extension
  1410.         except:
  1411.             pass
  1412.         if emptyForUnknown:
  1413.             return u""
  1414.         else:
  1415.             return u"unknown"
  1416.  
  1417.     ##
  1418.     # return keyword tags associated with the video separated by commas
  1419.     @returnsUnicode
  1420.     def getTags(self):
  1421.         self.confirmDBThread()
  1422.         try:
  1423.             return self.entry.categories.join(u", ")
  1424.         except:
  1425.             return u""
  1426.  
  1427.     ##
  1428.     # return the license associated with the video
  1429.     @returnsUnicode
  1430.     def getLicence(self):
  1431.  
  1432.         self.confirmDBThread()
  1433.         try:
  1434.             return self.entry.license
  1435.         except:
  1436.             try:
  1437.                 return self.getFeed().getLicense()
  1438.             except:
  1439.                 return u""
  1440.  
  1441.     ##
  1442.     # return the people associated with the video, separated by commas
  1443.     @returnsUnicode
  1444.     def getPeople(self):
  1445.         ret = []
  1446.         self.confirmDBThread()
  1447.         try:
  1448.             for role in self.getFirstVideoEnclosure().roles:
  1449.                 for person in self.getFirstVideoEnclosure().roles[role]:
  1450.                     ret.append(person)
  1451.             for role in self.entry.roles:
  1452.                 for person in self.entry.roles[role]:
  1453.                     ret.append(person)
  1454.         except:
  1455.             pass
  1456.         return u', '.join(ret)
  1457.  
  1458.     ##
  1459.     # returns the URL of the webpage associated with the item
  1460.     def getLink(self):
  1461.         self.confirmDBThread()
  1462.         try:
  1463.             return self.entry.link.decode('ascii','replace')
  1464.         except:
  1465.             return u""
  1466.  
  1467.     ##
  1468.     # returns the URL of the payment page associated with the item
  1469.     def getPaymentLink(self):
  1470.         self.confirmDBThread()
  1471.         try:
  1472.             return self.getFirstVideoEnclosure().payment_url.decode('ascii','replace')
  1473.         except:
  1474.             try:
  1475.                 return self.entry.payment_url.decode('ascii','replace')
  1476.             except:
  1477.                 return u""
  1478.  
  1479.     ##
  1480.     # returns a snippet of HTML containing a link to the payment page
  1481.     # HTML has already been sanitized by feedparser
  1482.     @returnsUnicode
  1483.     def getPaymentHTML(self):
  1484.         self.confirmDBThread()
  1485.         try:
  1486.             ret = self.getFirstVideoEnclosure().payment_html
  1487.         except:
  1488.             try:
  1489.                 ret = self.entry.payment_html
  1490.             except:
  1491.                 ret = u""
  1492.         # feedparser returns escaped CDATA so we either have to change its
  1493.         # behavior when it parses dtv:paymentlink elements, or simply unescape
  1494.         # here...
  1495.         return u'<span>' + unescape(ret) + u'</span>'
  1496.  
  1497.     def makeContextMenu(self, templateName, view):
  1498.         c = app.controller # easier/shorter to type
  1499.         if self.isDownloaded():
  1500.             if templateName in ('playlist', 'playlist-folder'):
  1501.                 label = _('Remove From Playlist')
  1502.             else:
  1503.                 label = _('Remove From the Library')
  1504.             items = [
  1505.                 (lambda: c.playView(view, self.getID()), _('Play')),
  1506.                 (lambda: c.playView(view, self.getID(), True), 
  1507.                     _('Play Just This Video')),
  1508.                 (c.addToNewPlaylist, _('Add to new playlist')),
  1509.                 (c.removeCurrentItems, label),
  1510.             ]
  1511.             if self.getSeen():
  1512.                 items.append((self.markItemUnseen, _('Mark as Unwatched')))
  1513.             else:
  1514.                 items.append((self.markItemSeen, _('Mark as Watched')))
  1515.                 
  1516.             if self.downloader and self.downloader.getState() == 'finished' and self.downloader.getType() == 'bittorrent':
  1517.                 items.append((self.startUpload, _('Restart Upload')))
  1518.         elif self.getState() == 'downloading':
  1519.             items = [(self.expire, _('Cancel Download')), (self.pause, _('Pause Download'))]
  1520.         else:
  1521.             items = [(self.download, _('Download'))]
  1522.         return menu.makeMenu(items)
  1523.  
  1524.     ##
  1525.     # Updates an item with new data
  1526.     #
  1527.     # @param entry a dict object containing the new data
  1528.     def update(self, entry):
  1529.         UandA = self.getUandA()
  1530.         self.confirmDBThread()
  1531.         try:
  1532.             self.entry = entry
  1533.             self.iconCache.requestUpdate()
  1534.             self.updateReleaseDate()
  1535.             self._calcFirstEnc()
  1536.         finally:
  1537.             self.signalChange()
  1538.  
  1539.     def onDownloadFinished(self):
  1540.         """Called when the download for this item finishes."""
  1541.  
  1542.         self.confirmDBThread()
  1543.         self.downloadedTime = datetime.now()
  1544.         if not self.splitItem():
  1545.             self.signalChange()
  1546.         moviedata.movieDataUpdater.requestUpdate (self)
  1547.  
  1548.         for other in views.items:
  1549.             if other.downloader is None and other.getURL() == self.getURL():
  1550.                 other.downloader = self.downloader
  1551.                 self.downloader.addItem(other)
  1552.                 other.signalChange(needsSave=False)
  1553.         
  1554.         app.delegate.notifyDownloadCompleted(self)
  1555.  
  1556.     def getResizedScreenshot(self, width, height):
  1557.         try:
  1558.             return imageresize.getImage(self.resized_screenshots, width, height)
  1559.         except KeyError:
  1560.             return self.screenshot
  1561.  
  1562.     def resizeScreenshot(self):
  1563.         imageresize.removeResizedFiles(self.resized_screenshots)
  1564.         if self.screenshot:
  1565.             self.resized_screenshots = imageresize.multiResizeImage(
  1566.                     self.screenshot, self.ICON_CACHE_SIZES)
  1567.         else:
  1568.             self.resized_screenshots = {}
  1569.  
  1570.     def save(self):
  1571.         self.confirmDBThread()
  1572.         if self.keep != True:
  1573.             self.keep = True
  1574.             self.signalChange()
  1575.  
  1576.     ##
  1577.     # gets the time the video was downloaded
  1578.     # Only valid if the state of this item is "finished"
  1579.     def getDownloadedTime(self):
  1580.         if self.downloadedTime is None:
  1581.             return datetime.min
  1582.         else:
  1583.             return self.downloadedTime
  1584.  
  1585.     ##
  1586.     # Returns the filename of the first downloaded video or the empty string
  1587.     # NOTE: this will always return the absolute path to the file.
  1588.     @returnsFilename
  1589.     def getFilename(self):
  1590.         self.confirmDBThread()
  1591.         try:
  1592.             return self.downloader.getFilename()
  1593.         except:
  1594.             return FilenameType("")
  1595.  
  1596.     ##
  1597.     # Returns the filename of the first downloaded video or the empty string
  1598.     # NOTE: this will always return the absolute path to the file.
  1599.     @returnsFilename
  1600.     def getVideoFilename(self):
  1601.         self.confirmDBThread()
  1602.         if self.videoFilename:
  1603.             return os.path.join (self.getFilename(), self.videoFilename)
  1604.         else:
  1605.             return self.getFilename()
  1606.  
  1607.     def isNonVideoFile(self):
  1608.         # isContainerItem can be False or None.
  1609.         return self.isContainerItem != True and not self.isVideo
  1610.  
  1611.     def isExternal(self):
  1612.         """Returns True iff this item was not downloaded from a Democracy
  1613.         channel.
  1614.         """
  1615.         return self.feed_id is not None and self.getFeedURL() == 'dtv:manualFeed'
  1616.  
  1617.     def isPlayable(self):
  1618.         """Returns True iff this item should have a play button."""
  1619.         if not self.isContainerItem:
  1620.             return self.isDownloaded() and self.getVideoFilename()
  1621.         else:
  1622.             return self.isDownloaded() and len(self.getChildren()) > 0
  1623.  
  1624.     def getRSSEntry(self):
  1625.         self.confirmDBThread()
  1626.         return self.entry
  1627.  
  1628.     def migrateChildren (self, newdir):
  1629.         if self.isContainerItem:
  1630.             for item in self.getChildren():
  1631.                 item.migrate(newdir)
  1632.         
  1633.  
  1634.     def remove(self):
  1635.         if self.downloader is not None:
  1636.             self.downloader.removeItem(self)
  1637.             self.downloader = None
  1638.         if self.iconCache is not None:
  1639.             self.iconCache.remove()
  1640.             self.iconCache = None
  1641.         imageresize.removeResizedFiles(self.resized_screenshots)
  1642.         if self.isContainerItem:
  1643.             for item in self.getChildren():
  1644.                 item.remove()
  1645.         DDBObject.remove(self)
  1646.  
  1647.     def setupLinks(self):
  1648.         """This is called after we restore the database.  Since we don't store
  1649.         references between objects, we need a way to reconnect downloaders to
  1650.         the items after the restore.
  1651.         """
  1652.         
  1653.         if not isinstance (self, FileItem) and self.downloader is None:
  1654.             self.downloader = downloader.getExistingDownloader(self)
  1655.             if self.downloader is not None:
  1656.                 self.signalChange(needsSave=False)
  1657.         self.splitItem()
  1658.         # This must come after reconnecting the downloader
  1659.         if self.isContainerItem is not None and not os.path.exists(self.getFilename()):
  1660.             self.executeExpire()
  1661.             return
  1662.         if self.screenshot and not os.path.exists(self.screenshot):
  1663.             self.screenshot = None
  1664.             self.signalChange()
  1665.         if self.duration is None or self.screenshot is None:
  1666.             moviedata.movieDataUpdater.requestUpdate (self)
  1667.  
  1668.     def __str__(self):
  1669.         return "Item - %s" % self.getTitle()
  1670.  
  1671. def reconnectDownloaders():
  1672.     reconnected = set()
  1673.     for item in views.items:
  1674.         item.setupLinks()
  1675.         reconnected.add(item.downloader)
  1676.     for downloader in views.remoteDownloads:
  1677.         if downloader not in reconnected:
  1678.             logging.warn("removing orphaned downloader: %s", downloader.url)
  1679.             downloader.remove()
  1680.     manualFeed = util.getSingletonDDBObject(views.manualFeed)
  1681.     manualItems = views.items.filterWithIndex(indexes.itemsByFeed,
  1682.             manualFeed.getID())
  1683.     for item in manualItems:
  1684.         if item.downloader is None and item.__class__ == Item:
  1685.             logging.warn("removing cancelled external torrent: %s", item)
  1686.             item.remove()
  1687.  
  1688. def getEntryForFile(filename):
  1689.     return FeedParserDict({'title':platformutils.filenameToUnicode(os.path.basename(filename)),
  1690.             'enclosures':[{'url': resources.url(filename)}]})
  1691.  
  1692. def getEntryForURL(url, contentType=None):
  1693.     if contentType is None:
  1694.         contentType = u'video/x-unknown'
  1695.     else:
  1696.         contentType = unicode(contentType)
  1697.     return FeedParserDict({'title' : url,
  1698.             'enclosures':[{'url' : url, 'type' : contentType}]})
  1699.  
  1700. ##
  1701. # An Item that exists as a local file
  1702. class FileItem(Item):
  1703.  
  1704.     def __init__(self,filename, feed_id=None, parent_id=None, offsetPath=None, deleted=False):
  1705.         checkF(filename)
  1706.         filename = os.path.abspath(filename)
  1707.         self.filename = filename
  1708.         self.deleted = deleted
  1709.         self.offsetPath = offsetPath
  1710.         self.shortFilename = cleanFilename(os.path.basename(self.filename))
  1711.         Item.__init__(self, getEntryForFile(filename), feed_id=feed_id, parent_id=parent_id)
  1712.         moviedata.movieDataUpdater.requestUpdate (self)
  1713.  
  1714.     @returnsUnicode
  1715.     def getState(self):
  1716.         if self.deleted:
  1717.             return u"expired"
  1718.         elif self.getSeen():
  1719.             return u"saved"
  1720.         else:
  1721.             return u"newly-downloaded"
  1722.  
  1723.     def getChannelCategory(self):
  1724.         """Get the category to use for the channel template.  
  1725.         
  1726.         This method is similar to getState(), but has some subtle differences.
  1727.         getState() is used by the download-item template and is usually more
  1728.         useful to determine what's actually happening with an item.
  1729.         getChannelCategory() is used by by the channel template to figure out
  1730.         which heading to put an item under.
  1731.  
  1732.         * downloading and not-downloaded are grouped together as
  1733.           not-downloaded
  1734.         * Items are always new if their feed hasn't been marked as viewed
  1735.           after the item's pub date.  This is so that when a user gets a list
  1736.           of items and starts downloading them, the list doesn't reorder
  1737.           itself.
  1738.         * Child items match their parents for expiring, where in
  1739.           getState, they always act as not expiring.
  1740.         """
  1741.  
  1742.         self.confirmDBThread()
  1743.         if self.deleted:
  1744.             return u'expired'
  1745.         elif not self.getSeen():
  1746.             return u'newly-downloaded'
  1747.         else:
  1748.             if self.parent_id and self.getParent().getExpiring():
  1749.                 return u'expiring'
  1750.             else:
  1751.                 return u'saved'
  1752.  
  1753.     def getExpiring(self):
  1754.         return False
  1755.  
  1756.     def showSaveButton(self):
  1757.         return False
  1758.  
  1759.     def getViewed(self):
  1760.         return True
  1761.  
  1762.     def isExternal(self):
  1763.         return self.parent_id is None
  1764.  
  1765.     def executeExpire(self):
  1766.         self.confirmDBThread()
  1767.         self.removeFromPlaylists()
  1768.         if self.isContainerItem:
  1769.             for item in self.getChildren():
  1770.                 item.remove()
  1771.         if not os.path.exists (self.filename):
  1772.             # item whose file has been deleted outside of DP
  1773.             self.remove()
  1774.         elif self.feed_id is None: 
  1775.             self.deleted = True
  1776.             self.signalChange()
  1777.         else:
  1778.             # external item that the user deleted in DP
  1779.             url = self.getFeedURL()
  1780.             if url.startswith ("dtv:manualFeed") or url.startswith ("dtv:singleFeed"):
  1781.                 self.remove()
  1782.             else:
  1783.                 self.deleted = True
  1784.                 self.signalChange()
  1785.  
  1786.     def deleteFiles(self):
  1787.         try:
  1788.             if self.getParent():
  1789.                 dler = self.getParent().downloader
  1790.                 if dler:
  1791.                     dler.stop(False)
  1792.             if os.path.isfile(self.filename):
  1793.                 os.remove(self.filename)
  1794.             elif os.path.isdir(self.filename):
  1795.                 shutil.rmtree(self.filename)
  1796.         except:
  1797.             logging.warn("WARNING: error deleting files:\n%s",
  1798.                     traceback.format_exc())
  1799.  
  1800.     def getDownloadedTime(self):
  1801.         self.confirmDBThread()
  1802.         try:
  1803.             return datetime.fromtimestamp(os.path.getctime(self.filename))
  1804.         except:
  1805.             return datetime.min
  1806.  
  1807.     @returnsFilename
  1808.     def getFilename(self):
  1809.         try:
  1810.             return self.filename
  1811.         except:
  1812.             return FilenameType("")
  1813.  
  1814.     def download(self,autodl=False):
  1815.         self.deleted = False
  1816.         self.signalChange()
  1817.  
  1818.     def updateReleaseDate(self):
  1819.         # This should be called whenever we get a new entry
  1820.         try:
  1821.             self.releaseDateObj = datetime.fromtimestamp(os.path.getmtime(self.filename))
  1822.         except:
  1823.             self.releaseDateObj = datetime.min
  1824.  
  1825.     def getReleaseDateObj(self):
  1826.         if self.parent_id:
  1827.             return self.getParent().releaseDateObj
  1828.         else:
  1829.             return self.releaseDateObj
  1830.  
  1831.     def migrate(self, newDir):
  1832.         self.confirmDBThread()
  1833.         if self.parent_id:
  1834.             parent = self.getParent()
  1835.             self.filename = os.path.join (parent.getFilename(), self.offsetPath)
  1836.             return
  1837.         if self.shortFilename is None:
  1838.             logging.warn("""\
  1839. can't migrate download because we don't have a shortFilename!
  1840. filename was %s""", stringify(self.filename))
  1841.             return
  1842.         newFilename = os.path.join(newDir, self.shortFilename)
  1843.         if self.filename == newFilename:
  1844.             return
  1845.         if os.path.exists(self.filename):
  1846.             newFilename = nextFreeFilename(newFilename)
  1847.             def callback():
  1848.                 self.filename = newFilename
  1849.                 self.signalChange()
  1850.             fileutil.migrate_file(self.filename, newFilename, callback)
  1851.         elif os.path.exists(newFilename):
  1852.             self.filename = newFilename
  1853.             self.signalChange()
  1854.         self.migrateChildren(newDir)
  1855.  
  1856.     def setupLinks(self):
  1857.         if self.shortFilename is None:
  1858.             if self.parent_id is None:
  1859.                 self.shortFilename = cleanFilename(os.path.basename(self.filename))
  1860.             else:
  1861.                 parent_file = self.getParent().getFilename()
  1862.                 if self.filename.startswith(parent_file):
  1863.                     self.shortFilename = cleanFilename(self.filename[len(parent_file):])
  1864.                 else:
  1865.                     logging.warn("%s is not a subdirectory of %s",
  1866.                             self.filename, parent_file)
  1867.         self.updateReleaseDate()
  1868.         Item.setupLinks(self)
  1869.  
  1870. def expireItems(items):
  1871.     if len(items) == 1:
  1872.         return items[0].expire()
  1873.  
  1874.     hasContainers = False
  1875.     hasExternalItems = False
  1876.     for item in items:
  1877.         if item.isContainerItem:
  1878.             hasContainers = True
  1879.         elif item.isExternal():
  1880.             hasExternalItems = True
  1881.         if hasContainers and hasExternalItems:
  1882.             break
  1883.  
  1884.     title = _("Removing %s items") % len(items)
  1885.     if hasExternalItems:
  1886.         description = _("""One or more of these videos was not downloaded \
  1887. from a channel.  Would you like to delete these items or just remove their \
  1888. entries from the Library?""")
  1889.     else:
  1890.         description = u"Are you sure you want to delete all %s videos?" % \
  1891.                 len(items)
  1892.  
  1893.     if hasContainers:
  1894.         description += u"\n\n" + _("""\
  1895. One or more of these items is a folder.  When you remove or delete a folder, \
  1896. any items inside that folder will also be removed or deleted.""")
  1897.  
  1898.     if hasExternalItems:
  1899.         d = dialogs.ThreeChoiceDialog(title, description,
  1900.                 dialogs.BUTTON_REMOVE_ENTRY, dialogs.BUTTON_DELETE_FILES,
  1901.                 dialogs.BUTTON_CANCEL)
  1902.     else:
  1903.         d = dialogs.ChoiceDialog(title, description, dialogs.BUTTON_OK,
  1904.                 dialogs.BUTTON_CANCEL)
  1905.  
  1906.     def callback(dialog):
  1907.         if dialog.choice == dialogs.BUTTON_DELETE_FILES:
  1908.             for item in items:
  1909.                 if item.idExists() and isinstance (item, FileItem):
  1910.                     item.deleteFiles()
  1911.         if dialog.choice in (dialogs.BUTTON_OK, dialogs.BUTTON_REMOVE_ENTRY,
  1912.                 dialogs.BUTTON_DELETE_FILES):
  1913.             for item in items:
  1914.                 if item.idExists():
  1915.                     item.executeExpire()
  1916.     d.run(callback)
  1917.  
  1918. def getFirstVideoEnclosure(entry):
  1919.     """Find the first video enclosure in a feedparser entry.  Returns the
  1920.     enclosure, or None if no video enclosure is found.
  1921.     """
  1922.  
  1923.     try:
  1924.         enclosures = entry.enclosures
  1925.     except (KeyError, AttributeError):
  1926.         return None
  1927.     for enclosure in enclosures:
  1928.         if filetypes.isVideoEnclosure(enclosure):
  1929.             return enclosure
  1930.     return None
  1931.  
  1932. @returnsUnicode
  1933. def formatRateForDetails(bytes):
  1934.     """Format a download/upload rate for the more-details view."""
  1935.     sizeFmt = util.formatSizeForUser(bytes, zeroString=u"-")
  1936.     if bytes > 0:
  1937.         return sizeFmt + u"/s"
  1938.     else:
  1939.         return sizeFmt
  1940.  
  1941. @returnsUnicode
  1942. def formatSizeForDetails(bytes):
  1943.     """Format a disk size for the more-details view."""
  1944.     return util.formatSizeForUser(bytes, zeroString=u"-")
  1945.